Skip to content

Add the Beasts TypeScript SDK and the self-service registration app - #21

Closed
loothero wants to merge 8 commits into
feat/nft-registry-integrationfrom
feat/sdk-and-app
Closed

Add the Beasts TypeScript SDK and the self-service registration app#21
loothero wants to merge 8 commits into
feat/nft-registry-integrationfrom
feat/sdk-and-app

Conversation

@loothero

@loothero loothero commented Aug 1, 2026

Copy link
Copy Markdown
Member

PR 5 of the community-beasts series — the artist-facing half. Stacked on #20, which is stacked on #19.

sdk/ — @provable-games/beasts-sdk

Everything static is offline. The 116-bit token ID is the Beast, so species, affixes, level, health, tier, type and the variant flags all decode without a node. Genesis species resolve fully offline too — tier and type are formulas, not tables. Only the name and art of community species need a chain read, and both cache per species.

  • encodeTokenId / decodeTokenId enforce the same ranges the contract does, including the affix-pair rule, so an encoded ID can never decode into a different Beast than the one passed in.
  • validateSpeciesName, validateRenderableArt, validateFactoryArt, validateArtSet mirror the contract's guards so a UI fails fast instead of failing a transaction. Explicitly a convenience, not the security boundary.
  • BeastsClient returns Call objects rather than sending them — the caller picks wallet popup, session, or a multicall batching several changes into one transaction.

Name tables are generated from beast_definitions.cairo (scripts/gen-tables.mjs), not transcribed: a hand-copied list of 75 species drifts silently, and a wrong species name is a wrong NFT.

app/ — Add a Beast

Register. All four art variants required — which one a Beast shows is decided by its token ID, so a partial set isn't a species. Art is checked against the contract's rules and decoded by the browser, because a file that won't decode here renders broken for every holder. Registration mints the artist's Genesis Beast in the same transaction.

Manage. Replace art, rotate/pause/lock minter, lock art, set a stats source, transfer the artist role, swap to a custom provider. All artist-only on-chain; the UI hides what the caller can't do and the contract is the real gate.

The Cartridge session pre-approves the registry's management entrypoints so an artist signs once and iterates freely — but deliberately not register_beast_with_art, which mints a provenance token and permanently assigns a species ID.

Verification

SDK unit tests 50 passing
SDK live tests 7 passing against the Sepolia deployment
Workspace typecheck clean
App build clean
Headless smoke test passing

The unit tests are anchored on two token IDs produced by the deployed contract, not by this SDK — 0x7006400010000000000000000001 (genesis Warlock) and 0x2e0064000a081000000000000004c ("Agony Bane" Gloomfang). A Cairo/TS layout drift fails the suite. The live tests prove the client's calldata and ABI decoding match the deployed contracts, which fixtures cannot.

The smoke test drives the built app in headless Chromium against Sepolia and confirms: the live chain read renders (76 species in the bestiary so far), name validation rejects bad"name, the preview derives power correctly (tier 1 → 50), and the dashboard loads real registry data (Gloomfang #76, Hunter · Tier 3 · Verified art).

Two things worth flagging

The preview card is an approximation. It mirrors the on-chain SVG's layout so an artist can judge their art in context before paying, but it is not the contract's renderer — that's beast_svg.cairo, and it only produces output once a species exists. Porting it byte-for-byte would make the preview exact; the design doc promised "the exact card", so this is a knowing gap, documented in app/README.md.

publicProvider() is unusable here. It pins RPC spec 0.8.1, which starknet 9.x dropped — it throws before the tree renders. The app uses jsonRpcProvider with an explicit node URL instead, matching what death-mountain-client does.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings August 1, 2026 02:51
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

GPT Code Review

[MEDIUM] scripts/gen-tables.mjs:30 - The parser ignores branch conditions and accepts the first 75 literals, allowing missing or reordered mappings to silently corrupt generated tables. Removing one get_beast_name branch still passes because the 'Unknown' fallback becomes entry 75.
Impact: The SDK can resolve valid token IDs to shifted or "Unknown" species names while the contract returns different metadata.
Fix: Parse each condition/literal pair, resolve its constant to the numeric ID, require every ID 1..expected exactly once, and validate the fallback separately before generating the array.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The SDK currently has a couple of concrete API/error-handling pitfalls (notably encodeTokenId integer validation and getArt behavior for genesis species) plus a portability issue in the smoke test script that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds a new TypeScript workspace to the Beasts repo: a reusable SDK for token ID/species/registry interactions plus an artist-facing “Add a Beast” self-service registration & management app. This complements the Cairo registry/NFT work in the earlier stacked PRs by providing client-side tooling, validation, and UI to exercise the on-chain flows.

Changes:

  • Introduce sdk/ (@provable-games/beasts-sdk) with token ID codec, generated name tables, contract-guard-mirroring validation helpers, and a BeastsClient that returns Call objects.
  • Introduce app/ (@provable-games/beasts-app) for registering and managing community species (art upload/validation, registry management actions, preview card, Cartridge session policy).
  • Set up pnpm workspace plumbing and a generator script to keep SDK tables in sync with src/beast_definitions.cairo.
File summaries
File Description
sdk/vitest.config.ts Adds Vitest configuration for SDK tests.
sdk/tsconfig.json Establishes SDK TypeScript compiler settings (ESM, strict, declarations).
sdk/tsconfig.build.json Defines SDK build-only TS config (src-only output).
sdk/test/validation.test.ts Unit tests for name/art/tier validation parity with contract guards.
sdk/test/tokenId.test.ts Unit tests for token ID encode/decode parity and validation behavior.
sdk/test/species.test.ts Unit tests for generated tables and offline genesis trait resolution.
sdk/test/live.test.ts Optional live RPC tests proving ABI/calldata/decoding matches Sepolia deployment.
sdk/src/validation.ts Implements client-side validation mirrors for contract guards (name/art/tier).
sdk/src/types.ts Defines SDK types/enums for beasts, definitions, and art sets.
sdk/src/tokenId.ts Implements 116-bit token ID layout encode/decode plus derived helpers.
sdk/src/tables.ts Generated genesis/prefix/suffix name tables sourced from Cairo definitions.
sdk/src/species.ts Offline helpers for genesis tier/type/name and full display names.
sdk/src/registry.ts Adds BeastsClient read helpers and transaction call builders for registry/NFT.
sdk/src/index.ts Exposes the SDK public surface area via a single entry point.
sdk/README.md Documents SDK usage patterns, offline decoding, validation, and tests.
sdk/package.json Defines SDK package metadata, build/test scripts, and deps/peerDeps.
sdk/.gitignore Ignores SDK build output and local deps.
scripts/gen-tables.mjs Generates sdk/src/tables.ts from src/beast_definitions.cairo.
pnpm-workspace.yaml Declares monorepo workspace packages (sdk, app).
package.json Adds workspace scripts for building/testing SDK and app, plus table generation.
app/vite.config.ts Adds Vite config for the React app.
app/tsconfig.json Establishes app TypeScript settings for Vite/React (noEmit, bundler resolution).
app/src/vite-env.d.ts Types Vite env vars for RPC URL and contract addresses.
app/src/styles.css Adds full app styling for register + dashboard UI.
app/src/main.tsx Boots Starknet React config and uses jsonRpcProvider with explicit RPC URL.
app/src/lib/chain.ts Centralizes chain/RPC/address config and Cartridge session policy.
app/src/lib/art.ts Adds art file loading, SDK-backed validation, browser decode checks, and cost estimate.
app/src/components/RegisterForm.tsx Registration form for name/type/tier/minter and required 4-variant art set.
app/src/components/Dashboard.tsx Artist controls for minting, art updates, provider swap, stats source, role transfer.
app/src/components/CardPreview.tsx Preview-only card approximation to help artists judge layout pre-registration.
app/src/components/ArtUpload.tsx Art upload component with per-slot validation errors and selection.
app/src/App.tsx App routing/state: register vs manage view, lookup, species count refresh, execution.
app/scripts/smoke.mjs Headless smoke test that drives the built app and verifies live chain reads/UI behavior.
app/README.md Documents app configuration, behavior, preview limitations, session policy, and smoke test.
app/package.json Defines app dependencies (SDK workspace link, starknet-react, Cartridge, Vite/React).
app/index.html Adds Vite HTML entrypoint.
app/.gitignore Ignores app build output, deps, and generated screenshots.
.gitignore Adds repo-level ignores for node/dist outputs alongside existing Cairo artifacts.
Review details
  • Files reviewed: 36/39 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread sdk/src/tokenId.ts
Comment on lines +161 to +185
const inRange = (name: string, value: number | bigint, max: bigint) => {
const v = BigInt(value);
if (v < 0n || v > max) throw new TokenIdError(`${name} out of range: ${value}`);
};

if (beast.id <= 0n) throw new TokenIdError('species ID must be positive');
inRange('id', beast.id, mask(WIDTH.id));
inRange('level', beast.level, mask(WIDTH.level));
inRange('health', beast.health, mask(WIDTH.health));

if (beast.prefix > 69) throw new TokenIdError(`prefix out of range: ${beast.prefix}`);
if (beast.suffix > 18) throw new TokenIdError(`suffix out of range: ${beast.suffix}`);
if (beast.prefix < 0 || beast.suffix < 0) throw new TokenIdError('affixes must be non-negative');
if ((beast.prefix === 0) !== (beast.suffix === 0)) {
throw new TokenIdError('invalid affix combo: prefix and suffix must both be zero, or neither');
}
if (beast.tier < 1 || beast.tier > 5) throw new TokenIdError(`tier out of range: ${beast.tier}`);
if (beast.beastType < 0 || beast.beastType > 2) {
throw new TokenIdError(`beast type out of range: ${beast.beastType}`);
}
if (beast.shiny !== 0 && beast.shiny !== 1) throw new TokenIdError('shiny must be 0 or 1');
if (beast.animated !== 0 && beast.animated !== 1) {
throw new TokenIdError('animated must be 0 or 1');
}
}
Comment thread sdk/src/registry.ts
Comment on lines +148 to +167
/** Art for a Beast, straight from its species' provider. */
async getArt(beast: Beast): Promise<string> {
const definition = await this.getDefinition(beast.id);
const raw = (await this.provider.callContract({
contractAddress: definition.artProvider,
entrypoint: 'get_data_uri',
calldata: CallData.compile([
beast.id.toString(),
beast.prefix,
beast.suffix,
beast.level,
beast.health,
beast.shiny,
beast.animated,
beast.tier,
beast.beastType,
]),
})) as string[];
return byteArray.stringFromByteArray(decodeByteArray(raw));
}
Comment thread app/scripts/smoke.mjs
Comment on lines +3 to +6
const browser = await chromium.launch({
executablePath:
'/home/ubuntu/.cache/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-linux64/chrome-headless-shell',
});
@loothero
loothero force-pushed the feat/nft-registry-integration branch from f32e7b6 to 0a9e422 Compare August 1, 2026 04:18
@loothero
loothero force-pushed the feat/nft-registry-integration branch from 0a9e422 to e7cfd75 Compare August 1, 2026 04:24
loothero and others added 5 commits July 31, 2026 21:24
PR 5 of the community-beasts series: the artist-facing half of the
permissionless registry.

SDK (`sdk/`)
- 116-bit token ID codec with BigInt, enforcing the same ranges the
  contract does — including the affix-pair rule, so an encoded ID can
  never decode into a different Beast than the one passed in.
- Genesis species resolve fully offline. Tier and type are formulas, not
  tables; only the name and art of *community* species need a chain read,
  and both cache per species.
- Client-side mirrors of the contract's name and art guards, so the UI can
  fail fast instead of failing a transaction.
- `BeastsClient` returns Call objects rather than sending them, leaving the
  signing strategy to the caller.

Name tables are generated from beast_definitions.cairo rather than
transcribed (`scripts/gen-tables.mjs`): a hand-copied list of 75 species
drifts silently, and a wrong species name is a wrong NFT.

App (`app/`)
- Register: four art variants (which one a Beast shows is decided by its
  token ID, so a partial set is not a species), name/type/tier/minter, one
  transaction. Art is checked against the contract's rules *and* decoded by
  the browser — a file that will not decode renders broken for every holder.
- Manage: art replacement, minter rotation/pause/lock, art lock, stats
  source, artist transfer, custom provider swap.
- The Cartridge session pre-approves management entrypoints but deliberately
  not registration, which mints a provenance token and permanently assigns a
  species ID.

Tests
- 50 SDK unit tests, anchored on two token IDs produced by the deployed
  contract rather than by this SDK, so a Cairo/TS layout drift fails.
- 7 opt-in live tests proving the client's calldata and ABI decoding match
  the deployed contracts.
- A headless smoke test driving the built app against Sepolia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Connect button called `connect({ connector: connectors[0] })` with
Controller as the only option and no error handling, so any failure was
invisible — the button simply appeared dead.

- Wallet picker listing Controller alongside injected wallets. Ready
  (formerly Argent) and Braavos are surfaced as recommended even when not
  installed, so the choice is visible rather than hidden.
- `useInjectedConnectors` scans window.starknet, so connector construction
  moved into a WalletProvider component that renders StarknetConfig.
- Connection failures are shown in the modal. A wallet whose extension is
  absent is disabled and labelled "Not detected" rather than silently
  doing nothing when clicked.
- Footer now names the network, so which chain the app is pointed at is
  never a guess.

Also fixes the connect-modal styles landing in src/styles.css at the repo
root instead of app/src/styles.css, which left the modal rendering as an
unstyled full-width block below the page content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A percentage height against an `aspect-ratio` parent resolves to `auto`, so
a square Beast sized itself from its own 1:1 ratio instead of the frame's:
a 32x32 image rendered 268x268 inside a 268x175 box and `overflow: hidden`
cropped the top and bottom. Artists saw a horizontal slice of their work.

Absolute inset gives the image a definite box to fit inside, so
`object-fit: contain` scales the whole thing down and centres it — matching
the contract's own layout, which draws a square art region inside a wider
frame.

The smoke test now uploads a 32x32 fixture with a border on all four sides
and asserts the rendered image fits its frame. The border is the tell: any
crop loses an edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manage was the submit button of a species-number input, so it did nothing
unless you already knew your ID. It now opens a list of everything the
connected wallet controls, each card linking into the existing dashboard.
Lookup-by-ID survives inside that view for anyone who does know the number.

The list keys off the artist role, not the Genesis Beast. The two start
together but diverge the moment either is transferred, and the role is what
the registry's permissioned entrypoints check — listing by token would
offer controls that revert.

SDK gains getSpeciesByArtist / getOwnedSpecies, derived from
BeastRegistered and ArtistTransferred events rather than by scanning IDs:
the registry keeps no artist index and species_count grows without bound.

BeastsAddresses gains a required fromBlock, which is a correctness fix
rather than an optimisation. Public nodes cap how far back getEvents will
look, and the one used here answers an over-wide range with an EMPTY
RESULT rather than an error — so scanning from genesis reports "this
wallet owns nothing" instead of failing. Sepolia is anchored to the
set_nft_address block, a provably safe floor: the registry rejects every
registration until the NFT is wired, so no BeastRegistered can predate it.

Live tests assert the lookup returns something, which is the guard against
that failure mode being silent again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The artist role is now ownership of the species' Genesis Beast, and the
collection exposes token_of_owner_by_index — so listing what a wallet
controls is a walk of its own tokens plus a local decode. A Genesis Beast
is any token with no affixes, and a token ID already carries its species.

That deletes the event scan and, with it, BeastsAddresses.fromBlock: the
whole silent-empty-range hazard was a consequence of needing events at all.

Transferring a species is now an ERC721 transfer of the creator token.
transferArtistRoleCall is replaced by transferGenesisBeastCall, and the
dashboard panel says plainly that sending the token sends every control on
the page — because a marketplace sale does exactly the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loothero and others added 3 commits July 31, 2026 21:38
Fresh deployment carrying owner enumeration and the Genesis-Beast artist
role. The art data contracts and stored_art_provider class were reused —
the provider recompiled to a byte-identical class hash, which the network
confirmed by rejecting the redeclare.

Enumeration is one request per token, so a wallet with a real collection
out-runs a free endpoint's per-second budget. The node answers -32011
rather than serving the call, which without handling reads as "this wallet
holds nothing" — the same silent-empty failure the event scan had, in a new
place. Batched with bounded concurrency and retried with backoff.

Verified on chain: enumeration returns the genesis Warlock at index 0,
get_artist resolves through the Genesis Beast, transferring that token
moved the role, and the previous holder's set_minter then reverted while
the new holder's succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verification table still carried the provider from the superseded
deployment. Each species gets its own instance, salted by species ID, so a
redeploy of the registry changes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three views the app was missing, all reachable by URL:

- /collection/0xabc… — every Beast an address holds. The address is a route
  parameter, not component state, so it can be edited to any wallet and
  shared; viewing someone else's collection needs no connection.
- /beasts — the bestiary, one card per species. Grouped by default because
  the collection is unbounded and one species can hold 1,243 Beasts; a flat
  list of tokens would be unreadable long before it was useful.
- /beasts/:id — every Beast of a species, strongest first, read from the
  contract's own per-species rank list rather than by scanning.

Routing is hash-based so every page is a real URL on a static host without
server rewrites.

SDK: getArt now mirrors the contract's own routing and resolves genesis
species through the four legacy art contracts, so the original 75 render
too. Adds species summaries, wallet token enumeration and per-species token
listing.

Art is fetched per Beast rather than per species: a community provider
receives the whole decoded Beast and may vary art by affix, tier or level,
so caching by species would show the wrong picture for exactly the providers
that make the interface worth having.

A tile whose art will not decode now says so instead of rendering a broken
image. The contract checks media type, base64 and magic bytes, but nothing
on chain can prove a payload is a real image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@loothero loothero closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants